WebSocket進一步解決了Long Polling會遇到的兩個問題:
不過WebSocket並不是超文本傳輸協定(HyperText Transfer Protocol,HTTP),但確實由HTTP開始的。因此首先是在瀏覽器發起Request之後,要進行協議的切換。Server會回傳切換資訊。
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: gx+UXBfX3qJcxachxkN/n8/3+WQ=
Sec-WebSocket-Extensions: permessage-deflate
Date: Sun, 04 Sep 2022 10:36:52 GMT
在這之後就可以進行雙向傳輸,當然以可以用於更新畫面資料。
實現複雜。不是所有瀏覽器都支援,不過現在主流瀏覽器基本支援。對於伺服器也有一定要求,在我經驗上許多免費服務器是無法使用相關技術的。
<!-- www-data/index.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<title>即時更新內容 - Websocket</title>
</head>
<body>
<h1 id="content"></h1>
</body>
<script defer type="module">
/***** 後續補充 ******/
</script>
</html>
在畫面上我們仍然有一個會更新內容的位置--#content
。
const DEFAULT_TIMEOUT = 30000 /*ms*/;
const HEATBEAT_INTVAL = 5000 /*ms*/;
let ws = new WebSocket(`ws://${window.location.host}/ws`)
let timer = null;
let timeout = DEFAULT_TIMEOUT;
let handlerMap = {};
const DEFAULT_HANDLER = (type, data) => {
console.error(`Can't handle ${type} event. data: `, data);
}
ws.addEventListener('open', async (event) => {
console.log("connnect to websocket...");
timer = setInterval(sendHeartBeat, HEATBEAT_INTVAL);
});
function sendHeartBeat() {
timeout -= HEATBEAT_INTVAL;
if(timeout <= 0) {
console.log("heatbeat timetout. closing websocket.");
ws.close();
return;
}
ws.send(JSON.stringify({
type: "HEATBEAT",
data: "SYN",
}));
}
ws.addEventListener('close', (event) => {
console.log("close websocket...");
clearInterval(timer);
});
在建立WebSocket連線以後(ws
),每隔一段時間(HEATBEAT_INTVAL
,5秒)去檢查一次連線是否依然正常。Server需要在限定時間(DEFAULT_TIMEOUT
,30秒)回應相對應訊息。
若Server未在限定時間(DEFAULT_TIMEOUT
,30秒)回應相對應訊息,表示連線出現問題,便斷開連線。
如果Server有回應的話,就重置timeout
:
handlerMap["HEATBEAT"] = (type, data) => {
if(data === "ACK") {
console.debug('heartbeat');
timeout = DEFAULT_TIMEOUT;
}
}
handlerMap
是簡單用於後續事件處理的註冊表。
同樣在建立連線後,需要向服務器註冊訂閱content.txt
的改變通知訊息。
const contentEl = document.querySelector('#content');
let handlerMap = {};
ws.addEventListener('open', async (event) => {
await ws.send(JSON.stringify({
type: "SUB",
data: 'content.txt',
}))
});
對於Server回傳訊息,透過handlerMap
註冊表簡單進行分派處理。
ws.addEventListener('message', (event) => {
let { type, data } = JSON.parse(event.data);
const handler = handlerMap[type] ?? DEFAULT_HANDLER;
handler(type, data);
});
最重要的是接受更新訊息,並將新內容更新在畫面上:
handlerMap["UPDATE"] = (type, data) => {
if (data['content.txt']){
contentEl.innerText = data['content.txt'];
}
}
這次還需要用到websockets
這個套件:
pip install websockets
引入套件
from fastapi import FastAPI, WebSocket
index.html
部分不變
@app.get('/index.html', response_class=HTMLResponse)
async def index():
return FileResponse('index.html')
然後添加一段建立WebSocket連線的EndPoint
@app.websocket('/ws')
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept()
while True:
data = await websocket.receive_json()
if data['type'] == 'HEATBEAT':
if data['data'] == 'SYN':
await websocket.send_json({"type": "HEATBEAT", "data": "ACK"})
continue
if data['type'] == 'SUB':
_file = data['data']
message = ""
with open(_file) as f:
message = f.read()
await websocket.send_json({
"type": "UPDATE",
"data": {
_file: message,
},
})
tr = threading.Thread(target=subModifyFileEvent,
args=(_file, websocket))
tr.start()
主要處理兩種事件類型。先是實現Heatbeat,讓前端畫面可以確認服務狀態:
data = await websocket.receive_json()
if data['type'] == 'HEATBEAT':
if data['data'] == 'SYN':
await websocket.send_json({"type": "HEATBEAT", "data": "ACK"})
continue
另一個是處理訂閱狀態。先在一開始回傳檔案內容
if data['type'] == 'SUB':
_file = data['data']
message = ""
with open(_file) as f:
message = f.read()
await websocket.send_json({
"type": "UPDATE",
"data": {
_file: message,
},
})
tr = threading.Thread(target=subModifyFileEvent,
args=(_file, websocket))
tr.start()
爲了避免阻塞,建立一個Thread
去監看檔案是否被修改過。這個新建立的Thread
當檔案被修改過,便會回傳一個更新事件。
監看檔案的方式與Long Polling使用watchdog
基本一致。
def subModifyFileEvent(file_path: str, websocket: WebSocket):
file_stat: FileStat = { "modified": False }
observer = Observer()
observer.schedule(WatchDogEvent(file_stat), CONTENT_FILE, recursive=False)
observer.start()
try:
while True:
if file_stat["modified"]:
message = ""
with open(file_path) as f:
message = f.read()
try:
if websocket.state == 2 or websocket.state == 3: # CLOSING or CLOSED
break
websocket.send_json({
"type": "UPDATE",
"data": {
file_path: message,
},
}).send(None)
except StopIteration:
...
file_stat["modified"] = False
time.sleep(1)
except KeyboardInterrupt:
observer.stop()
observer.stop()
這部分程式碼應該還有很多沒有考慮到的地方,只作爲說明DEMO使用。
現在可以嘗試啓動服務器看看
uvicorn app:app
開啓瀏覽器瀏覽 http://localhost:8000/index.html
與先前不同的是,Heatbeat的行爲是雙向的。並且只使用了一個HTTP Reqeust。
實務上應該會使用其他更高級包裝過的工具、套件。
SignalR: 微軟的解決方案,封裝了所有的即時網頁技術,包含支援 IE。
Socket.IO: node.js 解決方案,封裝了 polling 及 websocket。
MQTT: 適合輕量級物聯網使用,封包較小可以支援大量的 client。
Service Worker: 離線推播,獨立 Thread 無法操作 dom,透過 PushManager 可以使用推播。^7
這些套件在WebSocket不可用的時候,會自動退化使用Polling、Long Polling、Server Send Event等方式。並且提供更多高級抽象的能力,寫起來更爲方便。
但基本上還是會建議好好了解一下WebSocket本身。
本文同時發表於我的隨筆